Rust 瀏覽器指紋實作:reqwest 與 wreq 的 TLS/HTTP/2 差異
把同一筆 API 請求改寫成 Rust,URL、Header 與 JSON 都相同,伺服器仍可能觀察到不同的 TLS 握手與 HTTP/2 連線參數。這些特徵主要來自 HTTP Client 的底層實作;修改 User-Agent 可以改變請求的自我描述,卻不會連帶換掉 TLS backend。
reqwest 適合建立一般 Rust HTTP Client 的基準;需要調整瀏覽器的傳輸特徵時,wreq 提供 Browser Emulation Profile。兩者都有 Builder、非同步請求與連線池,但 Profile、Cookie、Response ownership 和原生編譯依賴,會影響程式該如何組織。
Quick Start
固定使用 Rust 1.98.0、wreq 0.16.1 與 wreq-util 0.2.0,並以已安裝 rustup 為前提。Linux 的首次編譯會建置 BoringSSL;Ubuntu/Debian 可先安裝編譯工具:
sudo apt-get update
sudo apt-get install -y build-essential cmake perl pkg-config libclang-dev git
rustup toolchain install 1.98.0 --profile minimal
cargo +1.98.0 new fingerprint-quick-start
cd fingerprint-quick-start
cargo +1.98.0 add wreq@=0.16.1 --features json
cargo +1.98.0 add wreq-util@=0.2.0
cargo +1.98.0 add tokio@=1.53.1 --features macros,rt-multi-thread
cargo +1.98.0 add serde_json@=1.0.151
將 src/main.rs 換成以下程式,發送一筆套用 Chrome 124 Profile 的 GET:
use std::time::Duration;
use wreq::Client;
use wreq_util::Emulation;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.emulation(Emulation::Chrome124)
.timeout(Duration::from_secs(15))
.build()?;
let response = client
.get("https://tls.browserleaks.com/json")
.send()
.await?
.error_for_status()?;
println!("HTTP version: {:?}", response.version());
let data: serde_json::Value = response.json().await?;
println!("JA3N: {}", data["ja3n_hash"]);
Ok(())
}
cargo +1.98.0 run --locked
這次實際執行的結果:
HTTP version: HTTP/2.0
JA3N: "4c9ce26028c11d7544da00d3f7e4f45c"
emulation 指定連線使用的 Profile,timeout 限制請求與回應 Body 的總時間。先取出 version(),再呼叫 json(),是因為讀取 JSON 會消耗 Response。若輸出 HTTP/2.0,表示這次實際協商使用 HTTP/2;JA3N 則是診斷端點根據 TLS 握手計算的摘要。
Quick Start 的 Emulation::Chrome124 使用預設 macOS 平台。需要固定作業系統相關 Header 時,可再透過 Profile Builder 選擇 Windows、macOS 或 Linux;這些設定不會改變程式真正執行的作業系統。
Client 架構與固定版本
reqwest、wreq 與 TLS backend
reqwest 0.13.4 預設使用 rustls,也能透過 feature 改用 native-tls。wreq 0.16.1 使用 btls 串接 BoringSSL,並搭配可調整的 HTTP 協定實作;Browser Profile 由 wreq-util 提供。reqwest features、wreq 發行套件 與 wreq-util 發行套件 是版本核對的起點。
Rust application/Tokio
├─ reqwest → HTTP stack → rustls → TCP
└─ wreq + wreq-util Profile → 可調整的 HTTP stack → btls/BoringSSL → TCP
wreq 的 HTTP API 與非同步控制主要位於 Rust,TLS 仍涉及原生 BoringSSL。部署時可以交付編譯後的 binary,但建置環境仍需 C/C++ 工具鏈;不能把「Rust Client」解讀成完全沒有 native dependency。
舊名稱 rquest 的 GitHub 位址目前會轉向 wreq,crates.io 上的舊版本已被 yank。Yank 不會使現有 lockfile 立刻失效,但新的教學與依賴解析應採用目前的套件名稱。rquest repository、rquest crate。
另一條路徑是在 Rust 經 FFI 呼叫 libcurl-impersonate,或啟動對應的 curl subprocess。FFI 可以保留 libcurl 的 handle 與連線池;subprocess 則要自行處理程序生命週期、輸入輸出及錯誤轉換。既有系統若已維護 libcurl,這條路徑有整合價值;直接採用 wreq 則能讓請求與 Tokio、Rust 型別及錯誤處理留在同一個程序內。
Cargo、Feature Flags 與建置環境
2026-09-08 查核時,wreq 0.16.1 與 wreq-util 0.2.0 均為非預發布版本,最低 Rust 版本都是 1.98。網路上仍有 wreq 6.0.0-rc.*、舊版 utility crate 與不同 API 的範例;版本號較大的舊系列不能直接與現在的 API 混用。
完整配套程式位於 GitHub repository。要執行四組診斷與後續範例,可另開終端機下載專案;配套的 Cargo 與 Docker 指令均從 rust-wreq/ 目錄執行:
git clone https://github.com/hsunAlfred/http-client-fingerprint-examples.git
cd http-client-fingerprint-examples/rust-wreq
若已下載過同一 repository,直接進入其中的 rust-wreq/ 即可。配套 Cargo.toml 包含診斷 CLI 與後續範例需要的完整依賴:
[package]
name = "fingerprint-client-rs"
version = "0.1.0"
edition = "2024"
rust-version = "1.98"
publish = false
[dependencies]
clap = { version = "=4.6.6", features = ["derive"] }
futures-util = { version = "=0.3.34", features = ["sink"] }
reqwest = { version = "=0.13.4", default-features = false, features = ["rustls", "http2", "json", "query", "form", "stream"] }
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.151"
tempfile = "=3.27.0"
thiserror = "=2.0.20"
tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread", "fs", "io-util", "net", "time", "sync"] }
tokio-util = { version = "=0.7.18", features = ["io"] }
wreq = { version = "=0.16.1", features = ["json", "query", "form", "cookies", "multipart", "stream", "ws", "socks", "gzip", "brotli", "deflate", "zstd"] }
wreq-util = { version = "=0.2.0", features = ["emulation-compression"] }
[dev-dependencies]
tokio-tungstenite = "=0.29.0"
=0.16.1 代表精確版本限制;實際使用的全部間接依賴另由 Cargo.lock 固定。配套也提供 rust-toolchain.toml,使專案目錄中的 Cargo 指令使用 Rust 1.98.0。
| 實測環境 | 版本 |
|---|---|
| 主機/容器 | Ubuntu 22.04.5/Debian 12 Bookworm,x86_64 |
| rustc/cargo | 1.98.0/1.98.0 |
| CMake/libclang | 3.25.1/14 |
| wreq TLS binding | btls 0.5.6、btls-sys 0.5.6 |
| reqwest TLS backend | rustls 0.23.44,AWS-LC provider |
| Tokio | 1.53.1 |
這份 lockfile 也包含 wreq-rt 0.2.2-rc.4:頂層 wreq 是非預發布版本,不代表整條間接依賴都沒有 RC。升級時需要一起檢查 Profile、runtime 與底層 TLS/HTTP crate 的變化。
| Feature | 用途 |
|---|---|
wreq 預設 webpki-roots,tokio-rt |
Mozilla 信任根與 Tokio 執行環境整合 |
json,query,form |
對應 JSON、Query String 與表單編碼 API |
cookies |
Cookie Jar 與自動接收/附加 Cookie |
stream,multipart |
串流 Body 與 Multipart 上傳 |
ws,socks |
WebSocket 與 SOCKS Proxy |
gzip,brotli,deflate,zstd |
回應 Body 自動解壓縮 |
wreq-util emulation-compression |
Profile 中的壓縮相關 Header |
reqwest rustls,http2 |
明確選定對照組的 TLS backend 與 HTTP/2 支援 |
Feature Flags 是編譯時能力開關。啟用 Profile 的壓縮宣告時,也應提供相應解碼能力;只有 Header 宣告、沒有解碼能力,可能得到應用程式無法處理的壓縮 Body。這份 Cargo 設定已將兩者配對。
reqwest 明確關閉 default features,再列出所需能力,便於核對對照組。這也避免把 0.12 的 rustls-tls feature 名稱搬到 0.13;目前使用的是 rustls,而 query 與 form 也各有 feature。reqwest 0.13.4 Cargo.toml。
若其他 crate 同時引入 openssl-sys,應檢查它與 BoringSSL 的符號衝突。wreq 的 prefix-symbols 可用於 Linux/Android 的對應建置,不能直接推定所有平台都有相同行為。官方建置說明。
reqwest 與 wreq 的指紋實驗
四組 Client 與控制條件
同一個公開診斷端點,可以先比較以下四組 Client:
| 組別 | TLS/HTTP 實作 | Header/Profile 設定 |
|---|---|---|
reqwest |
rustls、reqwest HTTP stack | 一般 Client |
reqwest-ua |
與 reqwest 相同 | 只加入 Chrome 124 Windows User-Agent |
wreq |
BoringSSL、wreq HTTP stack | 不指定 Browser Profile |
wreq-chrome |
BoringSSL、wreq HTTP stack | Chrome 124+Windows Profile |
四組都使用相同 URL、相同主機網路出口、不自動使用 Proxy、不追蹤 Redirect,也不保存 Cookie。CLI 先以同一個 URL parser 解析輸入,將正規化後的字串交給兩套 Client,讓 dot segments、反斜線與 Unicode 路徑不會因 parser 不同而改變請求目標。每組以新的 Client 建立首筆連線,避免把不同的暖連線狀態混進初次握手比較。TLS、JA3/JA4 與 HTTP/2 欄位的判讀方式可對照 HTTP Client 與瀏覽器指紋。
從配套目錄執行四次單筆診斷,每次建立獨立 Client:
cargo run --locked -- --url https://tls.browserleaks.com/json --client reqwest
cargo run --locked -- --url https://tls.browserleaks.com/json --client reqwest-ua
cargo run --locked -- --url https://tls.browserleaks.com/json --client wreq
cargo run --locked -- --url https://tls.browserleaks.com/json --client wreq-chrome --profile chrome124
2026-09-08 在 Debian 12 容器、Ubuntu 主機的同一網路出口實測,四組都收到 HTTP 200,結果如下。原始白名單結果保存在 observations.json;這些 Hash 是當次觀察值,不是測試必須永遠符合的常數。
| Client | HTTP | JA3N | Akamai HTTP/2 Hash |
|---|---|---|---|
| reqwest | HTTP/2 | bb37f13a85d080ea42c708e55800985d |
9b5dcd077a77c5e324b91d8cc306fd5a |
| reqwest-ua | HTTP/2 | bb37f13a85d080ea42c708e55800985d |
9b5dcd077a77c5e324b91d8cc306fd5a |
| wreq | HTTP/2 | 620628e68f7a3fc36de7125c3112aec7 |
787b78994836bff666aa7c3258e52189 |
| wreq-chrome | HTTP/2 | 4c9ce26028c11d7544da00d3f7e4f45c |
52d84b11737d980aef856699f885ca86 |
reqwest 與 reqwest-ua 的 JA4 都是 t13d1011h2_61a7ad8aa9b6_0d308c48d2a3,普通 wreq 是 t13d2811h2_257f3020b3a2_78e6aca7449b,Chrome Profile 組則是 t13d1516h2_8daaf6152771_02713d6af862。
這次兩組 reqwest 的原始 JA3 不同,但 JA3N、JA4 與 HTTP/2 摘要一致;不能只看原始 JA3 的差異,就把它歸因於 User-Agent。Chrome Profile 組的 JA3N、JA4 與 Akamai Hash 也和 Python 篇的 curl_cffi chrome124 實測相同,但摘要一致仍不代表整個 Client 實作或所有封包一致。
只修改 User-Agent 的結果,可以用來觀察應用層文字與 TLS 實作之間的界線;這是 reqwest 作為一般 HTTP Client 的設計定位。套用 Profile 後,仍應將 TLS 摘要、HTTP/2 摘要與實際 Header 一起判讀。
JA3 可能因 Extension Permutation 變動;JA3N/JA4 則省略或正規化部分資訊。Hash 相同只能表示相應摘要相同,不能證明所有封包欄位、真實瀏覽器版本或操作身分相同。JA3N 的正規化規則也依診斷服務而異。
Browser、Platform 與 Header 一致性
沿用實驗中的 Chrome 124+Windows Profile,可以建立供一般 HTTP 範例重用的 Client。以下 Builder 擷取自 http.rs,另外加入 Cookie Store、重導與 timeout;前面的四組診斷仍維持各自的實驗控制條件:
pub fn build_client() -> wreq::Result<Client> {
Client::builder()
.emulation(
Emulation::builder()
.profile(Profile::Chrome124)
.platform(Platform::Windows)
.build(),
)
.no_proxy()
.cookie_store(true)
.redirect(Policy::limited(3))
.connect_timeout(Duration::from_secs(5))
.read_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(20))
.build()
}
profile 選擇瀏覽器版本的 TLS/HTTP 設定,platform 主要影響 User-Agent、Client Hints 等平台相關 Header;它不會把 Linux kernel 的 TCP 行為改成 Windows。http2 與 headers 則可控制是否帶入 Profile 的對應部分。wreq-util 0.2.0 Profile 設定。
先套用 emulation,再做必要的細部調整。Profile 會帶入 TLS、HTTP/1、HTTP/2 與 Header 設定;後續若覆寫 User-Agent 或 Client Hints,就要重新檢查彼此是否一致。Profile 的預設 Header 也不會自動理解目前是頁面導覽、fetch 或圖片請求,應用程式仍需依實際 API 語境設定 Header。
Client-level 與 Request-level 都能使用 emulation。Request-level 設定適合局部調整;需要隔離登入身分時,仍應以獨立 Client/Cookie Store 管理。連線分組不等於 Cookie 或業務狀態隔離。RequestBuilder source。
HTTP/2 與 HTTP/3 的驗證邊界
http1_only() 與 http2_only() 可以限制 wreq 的協定選擇。一般協商則應檢查 response.version(),不要只依「啟用 HTTP/2 feature」推定實際使用的版本;ALPN、伺服器能力與連線設定都會參與結果。
HTTP/2 指紋可能包含 SETTINGS 值與順序、初始 WINDOW_UPDATE、Pseudo-header 順序及 Priority 行為。Browser Profile 的原始設定可以作為核對材料,診斷端點的 Akamai Hash 則是其中一種摘要;若要確認實際 frame,仍需觀察握手後的 HTTP/2 流量。
HTTPS 的 HTTP/2 frame 位於 TLS 加密內。Wireshark 分析需要在對應環境取得可用的 TLS secrets,或於自有 TLS 終端觀察解密後流量。一般 tcpdump pcap 不能單憑 http2.type == 4 就讀出加密連線的 SETTINGS。這次驗證使用診斷端點與本機功能測試,沒有把它描述成真實 Chrome 的完整 pcap 對照。
wreq 0.16.1 尚未提供可用的 QUIC/HTTP/3 Client;即使型別中有 HTTP_3 或 HTTP/3 ALPN 常數,也不代表底層能完成 HTTP/3 傳輸。固定版的送出路徑只接受 HTTP/1.0、HTTP/1.1 與 HTTP/2。協定版本檢查 source。
reqwest 0.13.4 的 HTTP/3 仍屬不穩定功能,需要 http3 feature 與 reqwest_unstable 編譯設定。這與重現 Chrome 的 QUIC transport parameters 是不同能力;這裡的四組對照固定在 TLS/HTTP/2 範圍。reqwest HTTP/3 feature。
Request Body 與 Typed Response
Rust 的 Request Builder 負責累積 URL、Headers、Body 與網路選項;.send().await 才會真正送出請求。JSON 可直接由 serde 型別編碼,Response 也可以反序列化成明確的 struct,讓欄位缺失與型別錯誤在解碼階段被辨識。
http.rs 定義的 Item 包含名稱 name 與數量 count,並用同一個型別處理送出與回傳的 JSON:
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Item {
pub name: String,
pub count: u32,
}
以下函式使用前面的 Client;完整 imports 與執行入口位於同一檔案:
pub async fn create_item(client: &Client, url: &str, item: &Item) -> wreq::Result<Item> {
let response = client
.post(url)
.query(&[("source", "article"), ("view", "compact")])
.header("x-example", "rust-wreq")
.json(item)
.send()
.await?
.error_for_status()?;
// status()、headers() 只借用 response;json() 消費 body 的 ownership。
println!("status: {}", response.status());
println!("content-type: {:?}", response.headers().get("content-type"));
response.json::<Item>().await
}
pub async fn submit_form(client: &Client, url: &str) -> wreq::Result<String> {
client
.post(url)
.form(&[("title", "Rust HTTP"), ("lang", "zh-TW")])
.send()
.await?
.error_for_status()?
.text()
.await
}
query 負責 Query String 編碼,json 設定 JSON Body 與 Content-Type,form 則編碼表單。body 可接收原始位元組;PUT、PATCH、DELETE 也使用相同的 Request Builder 模型。Authentication 可由 basic_auth 或 bearer_auth 設定,值應由受控設定來源提供,避免寫進範例或 log。
HTTP 4xx/5xx 仍是成功收到的 HTTP Response;.send().await? 不會單憑狀態碼把它當成傳輸錯誤。error_for_status() 才會將這類回應轉成錯誤。需要保存錯誤狀態碼或應用層錯誤 Body 時,應先讀取必要 metadata,再依自有 API 契約處理。
bytes()、text()、json() 與 bytes_stream() 都消耗 Response。若同時需要 status、headers 與 Body,先複製少量必要 metadata,再選一種 Body 讀取方式;不能讀完 JSON 後再讀一次原始 Body。wreq 0.16 使用 .uri() 取得最終 URI,和 reqwest 的 .url() 也不同。Response API source。
content_length() 可能回傳 None。自動解壓縮後的 Body 大小也不一定等於線上 Content-Length,因此下載上限要根據實際讀到的 bytes 累計。
Redirect 與 Timeout
固定版本的 wreq Client 預設不追蹤 Redirect;如果應用需要跟隨,明確設定 redirect::Policy::limited(5)。Policy::default() 本身則代表最多十次,和 Client 的預設初始化並不相同。使用明確 Policy 可以避免被舊版註解或不同 library 的預設行為誤導。Client 初始化 source。
| 設定 | 控制範圍 |
|---|---|
connect_timeout |
DNS、TCP 連線嘗試、Proxy tunnel 與 TLS handshake |
timeout |
請求進入傳輸流程至 Response Body 讀取完成的總時間 |
read_timeout |
限制取得完整 Response Headers 前的等待;Body 階段每次成功取得 frame 後重新計時 |
Tokio timeout/timeout_at |
包住整個工作 Future,可涵蓋排隊、重試與非同步檔案寫入 |
wreq 0.16.1 的 read_timeout 分成兩個階段:請求進入傳輸流程起、完整 Headers 返回前,維持同一個計時器,因此連線與上傳期間的等待也可能計入;Body 則在首次 poll 時啟動新的計時,之後每次成功取得 frame 都重新計時。這不是單純的 socket read idle timeout,應用程式處理 chunk 太久,也可能耗盡下一次 Body 讀取的預算。總 timeout 則維持同一個 deadline,跨越內部 Redirect/Retry 與 Body。Timeout middleware、Response Future、Body timer。
Client 預設沒有這些請求 timeout,實務上應明確設定。Request Builder 的 timeout 與 read_timeout 可覆寫單筆設定;應用程式自行重建請求、多次 Retry 時,要共享另一個總 deadline,否則每次重新取得完整 timeout,整體等待可能遠超預期。
Redirect 同時會影響 Method、Body 重送與認證邊界。Streaming Body 通常不能複製;307/308 要保留 Body 時,不能假設任意 stream 都能重播。跨來源認證資料也應依實際 library 與自有策略驗證。
啟用 cookies feature 後,.cookie_store(true) 會建立 Cookie Store,接收 Set-Cookie 並在後續符合條件的請求帶入。若需要由多個 Client 明確共用同一份 Jar,可使用 .cookie_provider(Arc<Jar>);這個選擇代表 Cookie 狀態確實被共用。
Domain、Path、Secure 等屬性會影響 Cookie 是否附加。這是 HTTP 層的 Cookie 管理,並不包含頁面的 JavaScript、DOM、localStorage 或完整瀏覽器環境。Cookie Jar source。
Client 可以 clone 後分給不同 Future,clone 共用內部連線資源,不必再包一層 Mutex 把所有請求序列化。Client 應跨請求重用,讓 Keep-Alive、TLS session 與 HTTP/2 multiplexing 有機會發揮作用。不同登入身分、Cookie、Proxy 與 Profile 的組合,則應明確劃分 Client 的使用範圍。
完整讀完 Response Body 有助於重用 HTTP/1.1 連線。提前 drop Body 時,library 可能取消 stream 或放棄連線,不能把「取得 response headers」當成傳輸與資源回收已全部完成。HTTP/2 的多 stream 共用一條 TCP 連線,也使請求數、連線數與 stream 數不能直接畫上等號。
Proxy、CA 與網路設定
Proxy 路由與 DNS
Proxy::all 可以設定 HTTP 或 SOCKS Proxy;socks5:// 在本機解析目標名稱,socks5h:// 則把名稱交給代理。代理 URI 中的帳密、Proxy-Authorization 與完整連線錯誤都不適合直接輸出至共用 log。SOCKS connector source。
以下函式擷取自 network.rs;proxy_url 是代理 URI,例如本機測試用的 socks5h://127.0.0.1:1080:
pub fn proxy_client(proxy_url: &str) -> wreq::Result<Client> {
Client::builder()
.emulation(Emulation::Chrome124)
.no_proxy()
.proxy(Proxy::all(proxy_url)?)
.timeout(Duration::from_secs(20))
.build()
}
.no_proxy() 會清除 Client 的代理設定並停用自動代理來源。四組指紋實驗採用明確直連,讓代理環境變數不會悄悄改變傳輸路徑。實際部署若需要代理,應明確建立對應 Client,並把代理設定納入實驗紀錄。
Request-level 也可指定 Proxy,但頻繁切換 Proxy 會改變連線重用條件。CONNECT tunnel 通常保留到目標端的 TLS 握手;TLS inspection proxy 則終止並重新建立 TLS,目標端觀察到的可能是代理的握手。
CA 與 mTLS
wreq 0.16.1 使用 tls::trust::CertStore 與 tls::trust::Identity,不能直接搬用 reqwest 的 add_root_certificate 與 identity 範例。自有 CA 用來驗證伺服器;Client Certificate/Private Key 則供伺服器驗證用戶端,兩者的責任不同。
同一份 network.rs 的 mTLS Builder 接收 CA bundle、Client Certificate 與私鑰的 PEM bytes:
pub fn mtls_client(ca_pem: &[u8], cert_pem: &[u8], key_pem: &[u8]) -> wreq::Result<Client> {
// 這個 store 只信任所提供的 CA bundle;不是在預設 store 上追加。
let store = CertStore::from_pem_stack(ca_pem)?;
let identity = Identity::from_pkcs8_pem(cert_pem, key_pem)?;
Client::builder()
.emulation(Emulation::Chrome124)
.no_proxy()
.tls_cert_store(store)
.tls_identity(identity)
.timeout(Duration::from_secs(20))
.build()
}
CertStore::from_pem_stack 建立的是自訂信任集合,透過 tls_cert_store 設定後會取代預設 roots。需要同時信任公開 CA 與自有 CA 時,應明確建立合併的 trust store。Identity::from_pkcs8_pem 的私鑰輸入是未加密 PKCS#8 PEM;應由受控檔案或 secret mount 提供,避免放進 repository、image 或診斷輸出。CertStore source、Identity source。
驗證失敗時,依 CA chain、hostname、有效期間及 Client Certificate 要求排查。停用 certificate verification 會改變安全性,並不能修正錯誤的信任配置。
Tokio 併發與 Streaming
有界 Future 與取消
#[tokio::main] 建立 Runtime,.await 讓工作在等待 I/O 時交回執行權。非同步不代表無限併發;如果先為所有輸入建立並 spawn task,再在 task 裡等待 Semaphore,仍可能先累積大量 task 與輸入資料。
已知有界輸入可使用 buffer_unordered,讓同時被推進的工作數維持在設定範圍:
以下是配套 src/lib.rs 的 run 函式;Args、Client 與結果型別定義在同一檔案:
pub async fn run(args: &Args, output: &mut impl Write) -> Result<Summary, RunError> {
let url = args.validate()?;
let client = HttpClient::new(args).map_err(RunError::Client)?;
let work = async {
let mut pending = stream::iter(0..args.count)
.map(|index| diagnose(&client, args, url.as_str(), index))
.buffer_unordered(args.concurrency as usize);
let mut summary = Summary::default();
while let Some(row) = pending.next().await {
let encoded = serde_json::to_vec(&row).map_err(RunError::Serialization)?;
output.write_all(&encoded).map_err(RunError::Output)?;
output.write_all(b"\n").map_err(RunError::Output)?;
output.flush().map_err(RunError::Output)?;
if let Some(category) = row.error {
summary.failed += 1;
*summary.errors.entry(category).or_default() += 1;
} else {
summary.success += 1;
}
}
Ok(summary)
};
tokio::time::timeout(Duration::from_millis(args.deadline_ms), work)
.await
.map_err(|_| RunError::Deadline)?
}
concurrency 是最多同時執行的工作數,不是每秒請求數。例子以完成順序回傳,index 保留從 0 開始的原始序號;如果需要輸入順序,必須額外排序或使用有順序的緩衝策略,並評估等待較慢工作造成的記憶體與延遲。
join_all 會一次持有整批 Future,適合已知很小的固定集合;FuturesUnordered 可以持續加入工作,但仍需由呼叫端限制加入量。Semaphore 適合在不同呼叫路徑間共用額度,應在 spawn 前取得 permit,或讓 producer 本身受到限制。
單筆失敗可以轉成結果列,讓其餘請求繼續;配套的全批次 deadline 則由外層 timeout 控制。Tokio timeout 採合作式取消,必須在 Future 交回執行權時才能生效。配套 CLI 的 stdout/檔案輸出使用同步 Write,所以批次 deadline 不能強制中斷阻塞中的寫入;JSON 解析也另以 Body 上限約束。
Drop 一個未完成的 request Future,會停止該 Future 後續被 poll;已經送到伺服器的操作仍可能完成,所以取消不能當成遠端交易回滾。Tokio timeout、buffer_unordered。
Streaming Download 與檔案提交
大檔案不宜用 bytes() 一次收進記憶體。bytes_stream() 可以逐 chunk 讀取,配合 tokio::fs::File 寫入檔案;應用程式等待寫入完成後才讀下一塊,可避免自行建立無界 queue。
以下函式擷取自 download.rs,完整檔案另含命令列執行入口:
use futures_util::StreamExt;
use std::{io, path::Path, time::Duration};
use tokio::io::AsyncWriteExt;
use wreq::Client;
use wreq_util::Emulation;
type Error = Box<dyn std::error::Error + Send + Sync>;
pub async fn download(
client: &Client,
url: &str,
destination: &Path,
max_bytes: u64,
) -> Result<u64, Error> {
if max_bytes == 0 {
return Err(
io::Error::new(io::ErrorKind::InvalidInput, "max_bytes must be positive").into(),
);
}
let response = client.get(url).send().await?.error_for_status()?;
if !response.status().is_success() {
return Err(io::Error::other(format!(
"download requires a 2xx response, received {}",
response.status()
))
.into());
}
let parent = destination
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
// 與目標放在同一個目錄,persist 才能使用同檔案系統的原子替換。
let temporary = tempfile::NamedTempFile::new_in(parent)?;
let mut output = tokio::fs::File::from_std(temporary.reopen()?);
let mut stream = response.bytes_stream();
let mut received = 0_u64;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
received = received
.checked_add(chunk.len() as u64)
.filter(|size| *size <= max_bytes)
.ok_or_else(|| io::Error::other("download exceeds max_bytes"))?;
output.write_all(&chunk).await?;
}
output.flush().await?;
output.sync_all().await?;
drop(output);
temporary
.persist(destination)
.map_err(|error| error.error)?;
Ok(received)
// 提早回傳錯誤時,NamedTempFile drop 會移除暫存檔。
}
下載函式先要求最終狀態為 2xx,再開始寫入暫存檔。error_for_status() 只拒絕 4xx/5xx,單獨使用它可能將未追蹤的 3xx 回應頁當成下載內容。
max_bytes 限制解壓縮後實際交給程式的 Body bytes,不能只看 Content-Length。這個限制也不等於整個程序的精確記憶體上限:協定、TLS 與解碼器本身仍有 buffer,單一 chunk 也可能在檢查前已經配置。
暫存檔必須建立於目的檔案相同的檔案系統,完成下載後再提交,才能使用原子替換。在原子替換前發生錯誤或 Future 正常被 drop 時,原有目的檔案保持完整,NamedTempFile 會嘗試刪除暫存檔。Drop 中的刪除失敗不會回傳錯誤;程序遭 SIGTERM/SIGKILL 終止、直接 exit 或機器斷電,也可能留下暫存檔,需要由後續清理流程處理。NamedTempFile 清理語意。原子替換處理的是讀者不會看到半份內容;斷電耐久性還涉及檔案與目錄同步,兩者需分開設計。
如果有可信來源的 SHA-256 等 checksum,可在逐 chunk 寫入時同步計算,確認相符後再替換。TLS 保護傳輸,來源提供的內容完整性契約則決定是否還需要額外 checksum。
Streaming Upload 與 Multipart
以下函式擷取自 upload.rs,完整檔案另含命令列執行入口:
use std::{io, path::Path, time::Duration};
use tokio_util::io::ReaderStream;
use wreq::{Body, Client, Response, multipart};
use wreq_util::Emulation;
type Error = Box<dyn std::error::Error + Send + Sync>;
fn require_success(response: Response) -> Result<Response, Error> {
let response = response.error_for_status()?;
if !response.status().is_success() {
return Err(io::Error::other(format!(
"upload requires a 2xx response, received {}",
response.status()
))
.into());
}
Ok(response)
}
pub async fn multipart_file(client: &Client, url: &str, path: &Path) -> Result<Response, Error> {
let form = multipart::Form::new()
.text("description", "article upload")
.file("file", path)
.await?;
require_success(client.post(url).multipart(form).send().await?)
}
pub async fn stream_file(client: &Client, url: &str, path: &Path) -> Result<Response, Error> {
let file = tokio::fs::File::open(path).await?;
// 來源檔案在傳輸期間必須維持不變,才可使用 metadata 的長度。
let length = file.metadata().await?.len();
let body = Body::wrap_stream(ReaderStream::new(file));
require_success(
client
.put(url)
.header("content-type", "application/octet-stream")
.header("content-length", length)
.body(body)
.send()
.await?,
)
}
ReaderStream 將非同步檔案讀取轉成 stream,Body::wrap_stream 再將它交給 HTTP 層。Multipart 的 Part 可設定檔名與 MIME type;這些 metadata 應依實際內容提供。當 stream 長度未知時,也要確認服務端接受對應的傳輸方式。
上傳範例也明確要求最終 2xx。若收到 307/308,而串流 Body 無法重播,wreq 可能直接回傳該重導回應;send() 成功與 error_for_status() 通過都不足以表示上傳已完成。
stream 不是自動可重播的 Body。Retry 或保留 Body 的 Redirect 必須重新開啟檔案、重建 stream,並確認輸入內容未在兩次傳輸間改變。對已產生副作用的上傳,還需要伺服器配合 idempotency key 或其他去重契約。
WebSocket 與連線狀態
啟用 ws feature 後,client.websocket(...) 可沿用 Client 的網路、Cookie 與 Emulation 設定建立握手。預設走 HTTP/1.1 Upgrade;HTTP/2 WebSocket 需要額外的 Extended CONNECT 與伺服器能力,不能因一般 GET 能使用 HTTP/2 就推定 WebSocket 也相同。
以下函式擷取自 websocket.rs,完整檔案另含命令列執行入口:
use futures_util::SinkExt;
use std::{io, time::Duration};
use tokio::time::timeout;
use wreq::{Client, ws::message::Message};
use wreq_util::Emulation;
type Error = Box<dyn std::error::Error + Send + Sync>;
pub async fn exchange(client: &Client, url: &str) -> Result<(), Error> {
timeout(Duration::from_secs(15), async {
let mut socket = client
.websocket(url)
.max_message_size(64 * 1024)
.max_frame_size(16 * 1024)
.send()
.await?
.into_websocket()
.await?;
socket.send(Message::text("hello")).await?;
socket.send(Message::binary(vec![1, 2, 3])).await?;
socket.send(Message::ping(vec![9])).await?;
let (mut text, mut binary, mut pong) = (false, false, false);
while !(text && binary && pong) {
let message = socket
.recv()
.await
.ok_or_else(|| io::Error::other("peer closed early"))??;
match message {
Message::Text(value) => text |= value.as_str() == "hello",
Message::Binary(value) => binary |= value.as_ref() == [1, 2, 3],
Message::Pong(value) => pong |= value.as_ref() == [9],
// tungstenite 自動排入 Pong;flush 確保及時送出。
Message::Ping(_) => socket.flush().await?,
Message::Close(_) => return Err(io::Error::other("peer closed before echo").into()),
}
}
// 保留 socket,送出 Close 後繼續讀取對端的 Close 回覆。
socket.send(Message::Close(None)).await?;
loop {
match socket.recv().await {
Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => continue,
Some(Err(error)) => return Err(error.into()),
None => return Err(io::Error::other("missing peer Close reply").into()),
}
}
Ok::<(), Error>(())
})
.await??;
Ok(())
}
Client 的 HTTP request timeout 不能當作整條 WebSocket 連線的工作期限。into_websocket() 另行等待升級,之後的訊息 stream 不再經過一般 Response Body timer;範例的外層 15 秒 timeout 因此包住握手、升級、訊息交換與 Close 確認。
send 是非同步操作,recv 回傳 Option<Result<Message, Error>>:None 表示 stream 已結束,Err 則是讀取或協定錯誤。Text 與 Binary 應分開處理;Text 在這個版本是 UTF-8 型別,Binary 是 bytes。
長時間連線還需要處理 Ping/Pong、Close 與讀寫 deadline。呼叫送出 Close 或 close(...),不等於已確認對端完成關閉握手;如果業務需要確認,應在有限時間內持續讀取對端 Close。控制訊息與正常資料訊息也不應混成同一種訂閱事件。WebSocket source。
需要同時讀寫時,可使用 Stream/Sink 的 split 模型,把輸出訊息經有界 channel 交給寫入工作;channel 滿時採等待、拒絕或依業務規則合併,避免無限排隊。訊息大小與排隊訊息數是兩種限制,都需要設定。
重新連線會建立新的 transport connection,舊連線的 subscription 與已確認進度不會自動恢復。可靠處理通常需要保存 cursor/sequence、重送訂閱、辨識重複訊息,再由應用層確認接收進度。Profile 負責握手特徵,這些恢復規則仍屬於業務協定。
Error、Retry 與診斷 CLI
錯誤分類與敏感資訊
傳輸錯誤、HTTP status 與 JSON 契約錯誤,需要保留不同意義:
| 類別 | 例子 | 處理方向 |
|---|---|---|
| Build/input | 非法 URI、錯誤 Client 配置、缺少憑證 | 在發送前拒絕 |
| Connect/TLS/Proxy | DNS 失敗、無法建立連線、驗證失敗 | 保留內部 source,依設定或網路原因排查 |
| Timeout | 連線、Body 或工作 deadline 超時 | 判斷是否可安全重試及剩餘預算 |
| HTTP status | 401、429、503 | 依狀態碼與 API 契約處理 |
| Body/decode | stream 中斷、無效 JSON | 區分傳輸損壞與格式不符 |
| Schema | 指紋欄位型別錯誤、沒有可用診斷欄位 | 拒絕把任意 JSON 當成成功結果 |
| Output | 磁碟或 stdout 寫入失敗 | 停止輸出並以非零碼結束 |
兩個 library 的分類能力不同。wreq 能辨識部分 DNS、TLS、Proxy 錯誤,但 is_tls() 沒有涵蓋所有包在連線錯誤內的 TLS handshake/憑證失敗;這些情況仍可能回到 connect_error。reqwest 的公開 predicates 也無法完整分拆 DNS/TLS,不能用 is_connect() 就宣稱已精確判定根因。內部可以用 thiserror 保存原始 source,外部輸出則採穩定分類;anyhow 適合執行入口整合不同錯誤,但不應把完整 error chain 直接當成公開診斷內容。
reqwest::Error::without_url() 與 wreq::Error::without_uri() 可以移除 URL/URI,卻不保證其他 source、Header 或自訂訊息已完全去敏。診斷紀錄採欄位白名單,比直接印出整個 Request、Response 或 Debug 物件更容易控制輸出契約。
Retry Budget 與冪等性
診斷 CLI 每筆 GET 只嘗試一次,並停用 library 的隱含協定重試,讓每列結果直接對應一次應用層發送。若業務需要 Retry,應在同一個工作 deadline 內安排每次嘗試,而不是每次重新取得完整預算。
| 決策條件 | Retry 策略 |
|---|---|
| 暫時性連線錯誤,操作可重送 | 在剩餘時間與次數額度內重試 |
| 429/503 含 Retry-After | 解析秒數或 HTTP-date;等待要求超過預算時結束 |
| 沒有 Retry-After 的可重試錯誤 | 有上限的 exponential backoff 加 jitter |
| 憑證驗證、認證或輸入格式錯誤 | 修正根因,通常不直接重試 |
| POST/上傳結果不明 | 先核對服務端冪等性與去重契約 |
| Body 無法重建 | 停止重送,不能重用已消耗的 stream |
伺服器要求等待 120 秒,工作只剩 10 秒時,不能把等待縮成 10 秒就提前重送;應回報本次預算不足。GET 通常適合有限重試,仍應確認端點行為;POST 即使帶了自己產生的 idempotency key,也必須由伺服器實際支援去重才有效。HTTP Retry-After、HTTP 冪等方法。
Diagnostic CLI 與 JSON Lines
配套 fingerprint-client-rs 將四組 Client、併發限制、Body 上限、錯誤分類與 JSON Lines 輸出整合為有限期命令列程式。它接收診斷端點,逐筆完成 GET 與欄位驗證,執行完畢後結束。
以下使用固定 Windows Chrome 124 Profile,最多同時執行兩筆 GET,將三筆結果寫入新檔案:
cargo run --locked -- \
--url https://tls.browserleaks.com/json \
--client wreq-chrome \
--profile chrome124 \
--count 3 \
--concurrency 2 \
--timeout-ms 10000 \
--deadline-ms 60000 \
--max-bytes 65536 \
--output result.jsonl
timeout-ms 是每筆工作開始後的毫秒上限,deadline-ms 是 Client 建立後整批非同步工作的毫秒上限,max-bytes 是單筆 Body 累積大小上限。--output 使用新建檔案模式,拒絕覆寫既有檔案;預設 - 則寫入 stdout。
每列包含 index、Client/Profile/Platform、HTTP status、HTTP version、latency_ms、attempts、error 與 fingerprint。latency_ms 不含尚未開始執行的排隊時間;attempts=1 表示一次應用層嘗試,不代表一個封包或一條 TCP 連線。
端點必須回傳頂層 JSON object。CLI 只接受 ja3_hash、ja3n_hash、ja4 與 akamai_hash 四個診斷欄位,其中 Hash 要求 32 個小寫十六進位字元,JA4 採固定格式檢查。只有空字串 akamai_hash 可以視為缺少 HTTP/2 資料;解析後的任何已知欄位若是 null、錯誤型別或無效格式,整筆都會拒絕,且至少需要一個有效診斷欄位。其他欄位全部略過。JSON 使用 serde_json::Value 解析,原始物件若有重複鍵,會採用最後一個值,再執行上述驗證。
error=null 代表 HTTP 與診斷格式檢查成功;尚未收到 Headers 時,status/version 是 null。失敗列只保留固定錯誤分類與已取得的 metadata,不輸出完整 URL、IP、Cookie、Header 或原始 Body。格式通過只能確認符合資料契約,不能代替對診斷端點與 Profile 結果的信任判斷。
正常跑完整批後,stderr 另輸出成功數、失敗數與錯誤分類統計。Exit code 0 代表全部成功,1 代表批次完成但有請求失敗,2 則代表參數、Client 建立、輸出或批次 deadline 造成中止。中止時 JSONL 可能只包含部分結果;輸出 I/O 失敗甚至可能留下不完整的最後一行。
輸出順序依完成時間決定,以 index 對回原始請求。公開服務可能改變回傳欄位或暫時不可用;出現缺少診斷欄位時,先核對 schema 與 HTTP 回應,不應把任意成功 JSON 當成已取得指紋。
Python 與 Rust 的整合取捨
| 面向 | Python curl_cffi | Rust wreq |
|---|---|---|
| 底層 | CFFI、libcurl-impersonate | Rust HTTP stack、btls/BoringSSL |
| API | Requests-like、Session | Builder、Future、型別化資料 |
| Async | AsyncSession、libcurl Multi | Tokio Runtime 與 Future |
| 共用狀態 | Session、Cookie 與 Curl handles | Client clone、connection pool、Cookie Store |
| Profile | impersonate Target | Browser Profile+Platform |
| HTTP/3 | 固定實測版本有對應能力 | wreq 0.16.1 尚無可用傳輸實作 |
| 部署 | Python runtime 與 wheel | 編譯後 binary,加上必要 runtime libraries/CA |
| 維護重點 | Python 與底層 libcurl 版本 | Rust toolchain、Cargo.lock 與 native build |
curl_cffi 的具體用法可對照 Python Browser Impersonation、Asyncio 與 WebSocket。兩者即使使用相同 Browser 名稱,也不代表同一份 Profile 資料或相同封包;比較時應同時固定版本、平台、網路與診斷方法。
選型應配合既有系統與部署條件。Python 方便接入既有資料處理與自動化流程;Rust 便於把型別、非同步工作與資源生命週期整合到原生服務。吞吐量與延遲需要分別量測冷連線、暖連線、payload 與併發,不能只依語言名稱推定。
配套建置與驗收
在已下載的 rust-wreq/ 目錄中,依 Rust README 執行:
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --locked --release
配套的 Dockerfile 固定 Rust 1.98.0 與 Debian Bookworm 編譯環境,runtime image 以非 root 身分執行 CLI。以下指令驗證建置、Compose 設定與啟動:
docker build -t fingerprint-client-rs .
docker compose config
docker compose up --abort-on-container-exit --exit-code-from fingerprint-client
docker run --rm fingerprint-client-rs --help
固定環境下完成的功能驗證如下;Rust README 另列出環境需求、指令與測試範圍。
| 驗證項目 | 結果 |
|---|---|
| fmt/Clippy | 格式檢查通過,all-targets 沒有 Clippy warning |
| Diagnostic CLI | 14 項本機測試通過,包含四組 URL 正規化一致性、錯誤分類、逾時、解壓後上限與輸出失敗 |
| HTTP/Streaming/WebSocket | 8 項功能測試通過,直接呼叫正文配套函式,包含非 2xx 拒絕與下載取消清理 |
read_timeout 邊界 |
2 項測試通過,確認 Headers 分段到達不重設計時,以及 Body 消費暫停可能觸發逾時 |
| 最小 Quick Start | 僅開頭四個依賴即可編譯執行,實際回傳 HTTP/2 與 JA3N |
| 公開指紋對照 | 四組 Client 各發送一筆 GET,皆回傳 200/HTTP/2 |
| release/Docker | release build、Compose 設定與啟動通過;runtime HTTPS 診斷為 200/HTTP/2 |
正文程式另由 python3 verify_article.py 確認與配套原始碼一致。Proxy 與 mTLS 範例已編譯,但沒有實際代理連線或雙向 TLS 握手驗收;Wireshark 解密、真實 Chrome 封包對照、WebSocket 自動重連及跨平台 benchmark 也不屬於這次已完成的驗證。